🎖️GitЯра🎖️
Commit 60feec646b8d3ada85c8dcf8893fcb7b2ebb3170
Parents : cc3b88d
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-01T12:29:31-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-01T12:29:31-05:00
fix(map): replace MarkerComposable with Canvas-rendered bitmaps (#5702)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Changes
4 files changed, 140 insertions(+), 35 deletions(-)
Diff
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 6ca71eb6a5..40f2756d9d 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -78,7 +78,7 @@ import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
-import com.google.maps.android.compose.MarkerComposable
+import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
@@ -102,6 +102,7 @@ import org.meshtastic.app.map.component.MapTypeDropdown
import org.meshtastic.app.map.component.NodeClusterMarkers
import org.meshtastic.app.map.component.NodeMapFilterDropdown
import org.meshtastic.app.map.component.WaypointMarkers
+import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@@ -125,7 +126,6 @@ import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
-import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -864,17 +864,17 @@ private fun NodeTrackOverlay(
}
if (index == sortedPositions.lastIndex) {
- MarkerComposable(
+ val chipIcon = rememberNodeChipDescriptor(focusedNode)
+ Marker(
state = markerState,
+ icon = chipIcon,
zIndex = activeNodeZIndex,
alpha = if (isHighPriority) 1.0f else 0.9f,
onClick = {
onPositionSelected?.invoke(position.time)
false // Allow default info window behavior
},
- ) {
- NodeChip(node = focusedNode)
- }
+ )
} else {
MarkerInfoWindowComposable(
state = markerState,
@@ -969,7 +969,6 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S
// region --- Traceroute Map Content ---
-@OptIn(MapsComposeExperimentalApi::class)
@Composable
private fun TracerouteMapContent(
forwardOffsetPoints: List<LatLng>,
@@ -998,7 +997,8 @@ private fun TracerouteMapContent(
}
displayNodes.forEach { node ->
val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
- MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
+ val chipIcon = rememberNodeChipDescriptor(node)
+ Marker(state = markerState, icon = chipIcon, zIndex = 4f)
}
}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
new file mode 100644
index 0000000000..6e07e49664
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map.component
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+import android.text.TextPaint
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalDensity
+import androidx.core.graphics.createBitmap
+import com.google.android.gms.maps.model.BitmapDescriptor
+import com.google.android.gms.maps.model.BitmapDescriptorFactory
+import org.meshtastic.core.model.Node
+
+private const val CHIP_CORNER_RADIUS_DP = 4f
+private const val CHIP_PADDING_HORIZONTAL_DP = 8f
+private const val CHIP_MIN_WIDTH_DP = 64f
+private const val CHIP_MIN_HEIGHT_DP = 28f
+private const val CHIP_TEXT_SIZE_SP = 14f
+private const val EMOJI_TEXT_SIZE_SP = 32f
+private const val EMOJI_PADDING_DP = 2f
+
+/**
+ * Renders a node chip marker as a [BitmapDescriptor] using Canvas — avoids the off-screen ComposeView pipeline in
+ * maps-compose's `MarkerComposable`/`rememberComposeBitmapDescriptor` which can crash with "The ComposeView was
+ * measured to have a width or height of zero" during subcomposition races (googlemaps/android-maps-compose#875).
+ */
+@Composable
+fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor {
+ val density = LocalDensity.current.density
+ val fontScale = LocalDensity.current.fontScale
+ return remember(node.num, node.user.short_name, node.colors, node.isIgnored) {
+ renderNodeChipBitmap(node, density, fontScale)
+ }
+}
+
+/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */
+@Composable
+fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
+ val density = LocalDensity.current.density
+ val fontScale = LocalDensity.current.fontScale
+ return remember(codePoint) { renderEmojiBitmap(codePoint, density, fontScale) }
+}
+
+private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor {
+ val (textColorInt, nodeColorInt) = node.colors
+ val scaledDensity = density * fontScale
+
+ val textPaint =
+ TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+ textSize = CHIP_TEXT_SIZE_SP * scaledDensity
+ typeface = Typeface.DEFAULT_BOLD
+ color = textColorInt
+ textAlign = Paint.Align.CENTER
+ isStrikeThruText = node.isIgnored
+ }
+ val label = node.user.short_name.ifEmpty { "???" }
+
+ val textWidth = textPaint.measureText(label)
+ val paddingH = CHIP_PADDING_HORIZONTAL_DP * density
+ val minWidth = CHIP_MIN_WIDTH_DP * density
+ val minHeight = CHIP_MIN_HEIGHT_DP * density
+
+ val width = maxOf(minWidth, textWidth + paddingH * 2).toInt()
+ val height = minHeight.toInt()
+
+ val bitmap = createBitmap(width, height)
+ val canvas = Canvas(bitmap)
+
+ val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = nodeColorInt }
+ val cornerRadius = CHIP_CORNER_RADIUS_DP * density
+ canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), cornerRadius, cornerRadius, bgPaint)
+
+ val textX = width / 2f
+ val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f)
+ canvas.drawText(label, textX, textY, textPaint)
+
+ return BitmapDescriptorFactory.fromBitmap(bitmap)
+}
+
+private fun renderEmojiBitmap(codePoint: Int, density: Float, fontScale: Float): BitmapDescriptor {
+ val scaledDensity = density * fontScale
+ val textPaint =
+ TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
+ textSize = EMOJI_TEXT_SIZE_SP * scaledDensity
+ textAlign = Paint.Align.CENTER
+ }
+ val emoji = String(Character.toChars(codePoint))
+ val padding = EMOJI_PADDING_DP * density
+
+ val textWidth = textPaint.measureText(emoji)
+ val metrics = textPaint.fontMetrics
+ val textHeight = metrics.descent - metrics.ascent
+
+ val width = (textWidth + padding * 2).toInt().coerceAtLeast(1)
+ val height = (textHeight + padding * 2).toInt().coerceAtLeast(1)
+
+ val bitmap = createBitmap(width, height)
+ val canvas = Canvas(bitmap)
+
+ val textX = width / 2f
+ val textY = padding - metrics.ascent
+ canvas.drawText(emoji, textX, textY, textPaint)
+
+ return BitmapDescriptorFactory.fromBitmap(bitmap)
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
index 227805ebdb..ed1044fc2e 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
@@ -16,22 +16,14 @@
*/
package org.meshtastic.app.map.component
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
-import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
-import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
-import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
@@ -39,7 +31,6 @@ import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
-@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,
@@ -65,11 +56,7 @@ fun WaypointMarkers(
}
val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
- val emojiText = convertIntToEmoji(iconCodePoint)
- val icon =
- rememberComposeBitmapDescriptor(iconCodePoint) {
- Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
- }
+ val icon = rememberEmojiMarkerDescriptor(iconCodePoint)
Marker(
state = markerState,
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
index 3ac4f605f1..53af32bb01 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
@@ -17,14 +17,12 @@
package org.meshtastic.app.node.component
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
@@ -37,17 +35,15 @@ import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
-import com.google.maps.android.compose.MapsComposeExperimentalApi
-import com.google.maps.android.compose.MarkerComposable
+import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
+import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.core.model.Node
-import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
-@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
@@ -57,14 +53,14 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
else -> ComposeMapColorScheme.LIGHT
}
- // Workaround for a maps-compose issue where MarkerComposable's internal ComposeView cannot find a
- // ViewTreeLifecycleOwner, causing a crash on bitmap rendering. We attach the current owners to the
- // window root view for the lifetime of the map.
+ // Defensive workaround: propagate ViewTreeLifecycleOwner to the root view so that
+ // any internal maps-compose ComposeView (e.g., info windows) can find the lifecycle
+ // when walking up the view tree.
//
// IMPORTANT: capture and restore the previous owners on dispose. This InlineMap is hosted inside the
// node-detail NavEntry, whose LocalLifecycleOwner is a transient, entry-scoped lifecycle. Leaving it
// attached to the activity root view after the entry is destroyed (e.g. navigating back to the node
- // list) would make every subsequently opened Popup/DropdownMenu inherit a DESTROYED lifecycle and
+ // list) would make subsequently opened Popups/DropdownMenus inherit a DESTROYED lifecycle and
// render at 0x0 (invisible). See the node-list popup regression.
val view = LocalView.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -86,6 +82,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val cameraState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM)
}
+ val markerIcon = rememberNodeChipDescriptor(node)
GoogleMap(
mapColorScheme = mapColorScheme,
@@ -114,9 +111,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
strokeWidth = 2f,
)
}
- MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) {
- NodeChip(node = node, modifier = Modifier.defaultMinSize(minWidth = 64.dp, minHeight = 28.dp))
- }
+ Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon)
}
}
}
Served by rngit 1.4.2 - Generated in 0.09s